// (c) 2014-2016 Don Coleman // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.megster.cordova.ble.central; import android.Manifest; import android.app.Activity; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.IntentFilter; import android.os.Handler; import android.os.Build; import android.provider.Settings; import org.apache.cordova.CallbackContext; import org.apache.cordova.CordovaArgs; import org.apache.cordova.CordovaPlugin; import org.apache.cordova.LOG; import org.apache.cordova.PermissionHelper; import org.apache.cordova.PluginResult; import org.json.JSONArray; import org.json.JSONObject; import org.json.JSONException; import java.util.*; public class BLECentralPlugin extends CordovaPlugin implements BluetoothAdapter.LeScanCallback { // actions private static final String SCAN = "scan"; private static final String START_SCAN = "startScan"; private static final String STOP_SCAN = "stopScan"; private static final String START_SCAN_WITH_OPTIONS = "startScanWithOptions"; private static final String LIST = "list"; private static final String CONNECT = "connect"; private static final String DISCONNECT = "disconnect"; private static final String READ = "read"; private static final String WRITE = "write"; private static final String WRITE_WITHOUT_RESPONSE = "writeWithoutResponse"; private static final String READ_RSSI = "readRSSI"; private static final String START_NOTIFICATION = "startNotification"; // register for characteristic notification private static final String STOP_NOTIFICATION = "stopNotification"; // remove characteristic notification private static final String IS_ENABLED = "isEnabled"; private static final String IS_CONNECTED = "isConnected"; private static final String SETTINGS = "showBluetoothSettings"; private static final String ENABLE = "enable"; private static final String START_STATE_NOTIFICATIONS = "startStateNotifications"; private static final String STOP_STATE_NOTIFICATIONS = "stopStateNotifications"; // callbacks CallbackContext discoverCallback; private CallbackContext enableBluetoothCallback; private static final String TAG = "BLEPlugin"; private static final int REQUEST_ENABLE_BLUETOOTH = 1; BluetoothAdapter bluetoothAdapter; // key is the MAC Address Map<String, Peripheral> peripherals = new LinkedHashMap<String, Peripheral>(); // scan options boolean reportDuplicates = false; // Android 23 requires new permissions for BluetoothLeScanner.startScan() private static final String ACCESS_COARSE_LOCATION = Manifest.permission.ACCESS_COARSE_LOCATION; private static final int REQUEST_ACCESS_COARSE_LOCATION = 2; private static final int PERMISSION_DENIED_ERROR = 20; private CallbackContext permissionCallback; private UUID[] serviceUUIDs; private int scanSeconds; // Bluetooth state notification CallbackContext stateCallback; BroadcastReceiver stateReceiver; Map<Integer, String> bluetoothStates = new Hashtable<Integer, String>() {{ put(BluetoothAdapter.STATE_OFF, "off"); put(BluetoothAdapter.STATE_TURNING_OFF, "turningOff"); put(BluetoothAdapter.STATE_ON, "on"); put(BluetoothAdapter.STATE_TURNING_ON, "turningOn"); }}; public void onDestroy() { removeStateListener(); } public void onReset() { removeStateListener(); } @Override public boolean execute(String action, CordovaArgs args, CallbackContext callbackContext) throws JSONException { LOG.d(TAG, "action = " + action); if (bluetoothAdapter == null) { Activity activity = cordova.getActivity(); boolean hardwareSupportsBLE = activity.getApplicationContext() .getPackageManager() .hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) && Build.VERSION.SDK_INT >= 18; if (!hardwareSupportsBLE) { LOG.w(TAG, "This hardware does not support Bluetooth Low Energy."); callbackContext.error("This hardware does not support Bluetooth Low Energy."); return false; } BluetoothManager bluetoothManager = (BluetoothManager) activity.getSystemService(Context.BLUETOOTH_SERVICE); bluetoothAdapter = bluetoothManager.getAdapter(); } boolean validAction = true; if (action.equals(SCAN)) { UUID[] serviceUUIDs = parseServiceUUIDList(args.getJSONArray(0)); int scanSeconds = args.getInt(1); resetScanOptions(); findLowEnergyDevices(callbackContext, serviceUUIDs, scanSeconds); } else if (action.equals(START_SCAN)) { UUID[] serviceUUIDs = parseServiceUUIDList(args.getJSONArray(0)); resetScanOptions(); findLowEnergyDevices(callbackContext, serviceUUIDs, -1); } else if (action.equals(STOP_SCAN)) { bluetoothAdapter.stopLeScan(this); callbackContext.success(); } else if (action.equals(LIST)) { listKnownDevices(callbackContext); } else if (action.equals(CONNECT)) { String macAddress = args.getString(0); connect(callbackContext, macAddress); } else if (action.equals(DISCONNECT)) { String macAddress = args.getString(0); disconnect(callbackContext, macAddress); } else if (action.equals(READ)) { String macAddress = args.getString(0); UUID serviceUUID = uuidFromString(args.getString(1)); UUID characteristicUUID = uuidFromString(args.getString(2)); read(callbackContext, macAddress, serviceUUID, characteristicUUID); } else if (action.equals(READ_RSSI)) { String macAddress = args.getString(0); readRSSI(callbackContext, macAddress); } else if (action.equals(WRITE)) { String macAddress = args.getString(0); UUID serviceUUID = uuidFromString(args.getString(1)); UUID characteristicUUID = uuidFromString(args.getString(2)); byte[] data = args.getArrayBuffer(3); int type = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT; write(callbackContext, macAddress, serviceUUID, characteristicUUID, data, type); } else if (action.equals(WRITE_WITHOUT_RESPONSE)) { String macAddress = args.getString(0); UUID serviceUUID = uuidFromString(args.getString(1)); UUID characteristicUUID = uuidFromString(args.getString(2)); byte[] data = args.getArrayBuffer(3); int type = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE; write(callbackContext, macAddress, serviceUUID, characteristicUUID, data, type); } else if (action.equals(START_NOTIFICATION)) { String macAddress = args.getString(0); UUID serviceUUID = uuidFromString(args.getString(1)); UUID characteristicUUID = uuidFromString(args.getString(2)); registerNotifyCallback(callbackContext, macAddress, serviceUUID, characteristicUUID); } else if (action.equals(STOP_NOTIFICATION)) { String macAddress = args.getString(0); UUID serviceUUID = uuidFromString(args.getString(1)); UUID characteristicUUID = uuidFromString(args.getString(2)); removeNotifyCallback(callbackContext, macAddress, serviceUUID, characteristicUUID); } else if (action.equals(IS_ENABLED)) { if (bluetoothAdapter.isEnabled()) { callbackContext.success(); } else { callbackContext.error("Bluetooth is disabled."); } } else if (action.equals(IS_CONNECTED)) { String macAddress = args.getString(0); if (peripherals.containsKey(macAddress) && peripherals.get(macAddress).isConnected()) { callbackContext.success(); } else { callbackContext.error("Not connected."); } } else if (action.equals(SETTINGS)) { Intent intent = new Intent(Settings.ACTION_BLUETOOTH_SETTINGS); cordova.getActivity().startActivity(intent); callbackContext.success(); } else if (action.equals(ENABLE)) { enableBluetoothCallback = callbackContext; Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); cordova.startActivityForResult(this, intent, REQUEST_ENABLE_BLUETOOTH); } else if (action.equals(START_STATE_NOTIFICATIONS)) { if (this.stateCallback != null) { callbackContext.error("State callback already registered."); } else { this.stateCallback = callbackContext; addStateListener(); sendBluetoothStateChange(bluetoothAdapter.getState()); } } else if (action.equals(STOP_STATE_NOTIFICATIONS)) { if (this.stateCallback != null) { // Clear callback in JavaScript without actually calling it PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT); result.setKeepCallback(false); this.stateCallback.sendPluginResult(result); this.stateCallback = null; } removeStateListener(); callbackContext.success(); } else if (action.equals(START_SCAN_WITH_OPTIONS)) { UUID[] serviceUUIDs = parseServiceUUIDList(args.getJSONArray(0)); JSONObject options = args.getJSONObject(1); resetScanOptions(); this.reportDuplicates = options.optBoolean("reportDuplicates", false); findLowEnergyDevices(callbackContext, serviceUUIDs, -1); } else { validAction = false; } return validAction; } private UUID[] parseServiceUUIDList(JSONArray jsonArray) throws JSONException { List<UUID> serviceUUIDs = new ArrayList<UUID>(); for(int i = 0; i < jsonArray.length(); i++){ String uuidString = jsonArray.getString(i); serviceUUIDs.add(uuidFromString(uuidString)); } return serviceUUIDs.toArray(new UUID[jsonArray.length()]); } private void onBluetoothStateChange(Intent intent) { final String action = intent.getAction(); if (action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)) { final int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR); sendBluetoothStateChange(state); } } private void sendBluetoothStateChange(int state) { if (this.stateCallback != null) { PluginResult result = new PluginResult(PluginResult.Status.OK, this.bluetoothStates.get(state)); result.setKeepCallback(true); this.stateCallback.sendPluginResult(result); } } private void addStateListener() { if (this.stateReceiver == null) { this.stateReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { onBluetoothStateChange(intent); } }; } try { IntentFilter intentFilter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED); webView.getContext().registerReceiver(this.stateReceiver, intentFilter); } catch (Exception e) { LOG.e(TAG, "Error registering state receiver: " + e.getMessage(), e); } } private void removeStateListener() { if (this.stateReceiver != null) { try { webView.getContext().unregisterReceiver(this.stateReceiver); } catch (Exception e) { LOG.e(TAG, "Error unregistering state receiver: " + e.getMessage(), e); } } this.stateCallback = null; this.stateReceiver = null; } private void connect(CallbackContext callbackContext, String macAddress) { Peripheral peripheral = peripherals.get(macAddress); if (peripheral != null) { peripheral.connect(callbackContext, cordova.getActivity()); } else { callbackContext.error("Peripheral " + macAddress + " not found."); } } private void disconnect(CallbackContext callbackContext, String macAddress) { Peripheral peripheral = peripherals.get(macAddress); if (peripheral != null) { peripheral.disconnect(); } callbackContext.success(); } private void read(CallbackContext callbackContext, String macAddress, UUID serviceUUID, UUID characteristicUUID) { Peripheral peripheral = peripherals.get(macAddress); if (peripheral == null) { callbackContext.error("Peripheral " + macAddress + " not found."); return; } if (!peripheral.isConnected()) { callbackContext.error("Peripheral " + macAddress + " is not connected."); return; } //peripheral.readCharacteristic(callbackContext, serviceUUID, characteristicUUID); peripheral.queueRead(callbackContext, serviceUUID, characteristicUUID); } private void readRSSI(CallbackContext callbackContext, String macAddress) { Peripheral peripheral = peripherals.get(macAddress); if (peripheral == null) { callbackContext.error("Peripheral " + macAddress + " not found."); return; } if (!peripheral.isConnected()) { callbackContext.error("Peripheral " + macAddress + " is not connected."); return; } peripheral.queueReadRSSI(callbackContext); } private void write(CallbackContext callbackContext, String macAddress, UUID serviceUUID, UUID characteristicUUID, byte[] data, int writeType) { Peripheral peripheral = peripherals.get(macAddress); if (peripheral == null) { callbackContext.error("Peripheral " + macAddress + " not found."); return; } if (!peripheral.isConnected()) { callbackContext.error("Peripheral " + macAddress + " is not connected."); return; } //peripheral.writeCharacteristic(callbackContext, serviceUUID, characteristicUUID, data, writeType); peripheral.queueWrite(callbackContext, serviceUUID, characteristicUUID, data, writeType); } private void registerNotifyCallback(CallbackContext callbackContext, String macAddress, UUID serviceUUID, UUID characteristicUUID) { Peripheral peripheral = peripherals.get(macAddress); if (peripheral != null) { if (!peripheral.isConnected()) { callbackContext.error("Peripheral " + macAddress + " is not connected."); return; } //peripheral.setOnDataCallback(serviceUUID, characteristicUUID, callbackContext); peripheral.queueRegisterNotifyCallback(callbackContext, serviceUUID, characteristicUUID); } else { callbackContext.error("Peripheral " + macAddress + " not found"); } } private void removeNotifyCallback(CallbackContext callbackContext, String macAddress, UUID serviceUUID, UUID characteristicUUID) { Peripheral peripheral = peripherals.get(macAddress); if (peripheral != null) { if (!peripheral.isConnected()) { callbackContext.error("Peripheral " + macAddress + " is not connected."); return; } peripheral.queueRemoveNotifyCallback(callbackContext, serviceUUID, characteristicUUID); } else { callbackContext.error("Peripheral " + macAddress + " not found"); } } private void findLowEnergyDevices(CallbackContext callbackContext, UUID[] serviceUUIDs, int scanSeconds) { if(!PermissionHelper.hasPermission(this, ACCESS_COARSE_LOCATION)) { // save info so we can call this method again after permissions are granted permissionCallback = callbackContext; this.serviceUUIDs = serviceUUIDs; this.scanSeconds = scanSeconds; PermissionHelper.requestPermission(this, REQUEST_ACCESS_COARSE_LOCATION, ACCESS_COARSE_LOCATION); return; } // ignore if currently scanning, alternately could return an error if (bluetoothAdapter.isDiscovering()) { return; } // clear non-connected cached peripherals for(Iterator<Map.Entry<String, Peripheral>> iterator = peripherals.entrySet().iterator(); iterator.hasNext(); ) { Map.Entry<String, Peripheral> entry = iterator.next(); Peripheral device = entry.getValue(); boolean connecting = device.isConnecting(); if (connecting){ LOG.d(TAG, "Not removing connecting device: " + device.getDevice().getAddress()); } if(!entry.getValue().isConnected() && !connecting) { iterator.remove(); } } discoverCallback = callbackContext; if (serviceUUIDs.length > 0) { bluetoothAdapter.startLeScan(serviceUUIDs, this); } else { bluetoothAdapter.startLeScan(this); } if (scanSeconds > 0) { Handler handler = new Handler(); handler.postDelayed(new Runnable() { @Override public void run() { LOG.d(TAG, "Stopping Scan"); BLECentralPlugin.this.bluetoothAdapter.stopLeScan(BLECentralPlugin.this); } }, scanSeconds * 1000); } PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT); result.setKeepCallback(true); callbackContext.sendPluginResult(result); } private void listKnownDevices(CallbackContext callbackContext) { JSONArray json = new JSONArray(); // do we care about consistent order? will peripherals.values() be in order? for (Map.Entry<String, Peripheral> entry : peripherals.entrySet()) { Peripheral peripheral = entry.getValue(); json.put(peripheral.asJSONObject()); } PluginResult result = new PluginResult(PluginResult.Status.OK, json); callbackContext.sendPluginResult(result); } @Override public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) { String address = device.getAddress(); boolean alreadyReported = peripherals.containsKey(address); if (!alreadyReported) { Peripheral peripheral = new Peripheral(device, rssi, scanRecord); peripherals.put(device.getAddress(), peripheral); if (discoverCallback != null) { PluginResult result = new PluginResult(PluginResult.Status.OK, peripheral.asJSONObject()); result.setKeepCallback(true); discoverCallback.sendPluginResult(result); } } else { Peripheral peripheral = peripherals.get(address); peripheral.update(rssi, scanRecord); if (reportDuplicates && discoverCallback != null) { PluginResult result = new PluginResult(PluginResult.Status.OK, peripheral.asJSONObject()); result.setKeepCallback(true); discoverCallback.sendPluginResult(result); } } } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_ENABLE_BLUETOOTH) { if (resultCode == Activity.RESULT_OK) { LOG.d(TAG, "User enabled Bluetooth"); if (enableBluetoothCallback != null) { enableBluetoothCallback.success(); } } else { LOG.d(TAG, "User did *NOT* enable Bluetooth"); if (enableBluetoothCallback != null) { enableBluetoothCallback.error("User did not enable Bluetooth"); } } enableBluetoothCallback = null; } } /* @Override */ public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) /* throws JSONException */ { for(int result:grantResults) { if(result == PackageManager.PERMISSION_DENIED) { LOG.d(TAG, "User *rejected* Coarse Location Access"); this.permissionCallback.sendPluginResult(new PluginResult(PluginResult.Status.ERROR, PERMISSION_DENIED_ERROR)); return; } } switch(requestCode) { case REQUEST_ACCESS_COARSE_LOCATION: LOG.d(TAG, "User granted Coarse Location Access"); findLowEnergyDevices(permissionCallback, serviceUUIDs, scanSeconds); this.permissionCallback = null; this.serviceUUIDs = null; this.scanSeconds = -1; break; } } private UUID uuidFromString(String uuid) { return UUIDHelper.uuidFromString(uuid); } /** * Reset the BLE scanning options */ private void resetScanOptions() { this.reportDuplicates = false; } }